var extend_object = function (obj, new_obj) {
    var name;

    if (obj === new_obj) {
        return obj;
    }

    for (name in new_obj) {
        if (new_obj[name] !== undefined) {
            obj[name] = new_obj[name];
        }
    }

    return obj;
};

var replace_object = function (obj, replace) {
    var name;

    if (obj === replace) {
        return obj;
    }

    for (name in replace) {
        if (obj[name] != undefined) {
            obj[name] = replace[name];
        }
    }

    return obj;
};

var array_map = function (array, callback) {
    var original_callback_params = Array.prototype.slice.call(arguments, 2),
        array_return = [],
        array_length = array.length,
        i;

    if (Array.prototype.map && array.map === Array.prototype.map) {
        array_return = Array.prototype.map.call(array, function (item) {
            var callback_params = original_callback_params.slice(0);
            callback_params.splice(0, 0, item);

            return callback.apply(this, callback_params);
        });
    } else {
        for (i = 0; i < array_length; i++) {
            callback_params = original_callback_params;
            callback_params.splice(0, 0, array[i]);
            array_return.push(callback.apply(this, callback_params));
        }
    }

    return array_return;
};

var array_flat = function (array) {
    var new_array = [],
        i;

    for (i = 0; i < array.length; i++) {
        new_array = new_array.concat(array[i]);
    }

    return new_array;
};

var coordsToLatLngs = function (coords, useGeoJSON) {
    var first_coord = coords[0],
        second_coord = coords[1];

    if (useGeoJSON) {
        first_coord = coords[1];
        second_coord = coords[0];
    }

    return new google.maps.LatLng(first_coord, second_coord);
};

var arrayToLatLng = function (coords, useGeoJSON) {
    var i;

    for (i = 0; i < coords.length; i++) {
        if (!(coords[i] instanceof google.maps.LatLng)) {
            if (coords[i].length > 0 && typeof (coords[i][0]) === "object") {
                coords[i] = arrayToLatLng(coords[i], useGeoJSON);
            } else {
                coords[i] = coordsToLatLngs(coords[i], useGeoJSON);
            }
        }
    }

    return coords;
};

var getElementsByClassName = function (class_name, context) {
    var element,
        _class = class_name.replace('.', '');

    if ('jQuery' in this && context) {
        element = $("." + _class, context)[0];
    } else {
        element = document.getElementsByClassName(_class)[0];
    }
    return element;

};

var getElementById = function (id, context) {
    var element,
        id = id.replace('#', '');

    if ('jQuery' in window && context) {
        element = $('#' + id, context)[0];
    } else {
        element = document.getElementById(id);
    }
    ;

    return element;
};

var findAbsolutePosition = function (obj) {
    var curleft = 0,
        curtop = 0;

    if (obj.getBoundingClientRect) {
        var rect = obj.getBoundingClientRect();
        var sx = -(window.scrollX ? window.scrollX : window.pageXOffset);
        var sy = -(window.scrollY ? window.scrollY : window.pageYOffset);

        return [(rect.left - sx), (rect.top - sy)];
    }

    if (obj.offsetParent) {
        do {
            curleft += obj.offsetLeft;
            curtop += obj.offsetTop;
        } while (obj = obj.offsetParent);
    }

    return [curleft, curtop];
};

var GMaps = (function (global) {
    "use strict";

    var doc = document;
    /**
     * Creates a new GMaps instance, including a Google Maps map.
     * @class GMaps
     * @constructs
     * @param {object} options - `options` accepts all the [MapOptions](https://developers.google.com/maps/documentation/javascript/reference#MapOptions) and [events](https://developers.google.com/maps/documentation/javascript/reference#Map) listed in the Google Maps API. Also accepts:
     * * `lat` (number): Latitude of the map's center
     * * `lng` (number): Longitude of the map's center
     * * `el` (string or HTMLElement): container where the map will be rendered
     * * `markerClusterer` (function): A function to create a marker cluster. You can use MarkerClusterer or MarkerClustererPlus.
     */
    var GMaps = function (options) {

        if (!(typeof window.google === 'object' && window.google.maps)) {
            if (typeof window.console === 'object' && window.console.error) {
                console.error('Google Maps API is required. Please register the following JavaScript library https://maps.googleapis.com/maps/api/js.');
            }

            return function () {
            };
        }

        if (!this) return new GMaps(options);

        options.zoom = options.zoom || 15;
        options.mapType = options.mapType || 'roadmap';

        var valueOrDefault = function (value, defaultValue) {
            return value === undefined ? defaultValue : value;
        };

        var self = this,
            i,
            events_that_hide_context_menu = [
                'bounds_changed', 'center_changed', 'click', 'dblclick', 'drag',
                'dragend', 'dragstart', 'idle', 'maptypeid_changed', 'projection_changed',
                'resize', 'tilesloaded', 'zoom_changed'
            ],
            events_that_doesnt_hide_context_menu = ['mousemove', 'mouseout', 'mouseover'],
            options_to_be_deleted = ['el', 'lat', 'lng', 'mapType', 'width', 'height', 'markerClusterer', 'enableNewStyle'],
            identifier = options.el || options.div,
            markerClustererFunction = options.markerClusterer,
            mapType = google.maps.MapTypeId[options.mapType.toUpperCase()],
            map_center = new google.maps.LatLng(options.lat, options.lng),
            zoomControl = valueOrDefault(options.zoomControl, true),
            zoomControlOpt = options.zoomControlOpt || {
                style: 'DEFAULT',
                position: 'TOP_LEFT'
            },
            zoomControlStyle = zoomControlOpt.style || 'DEFAULT',
            zoomControlPosition = zoomControlOpt.position || 'TOP_LEFT',
            panControl = valueOrDefault(options.panControl, true),
            mapTypeControl = valueOrDefault(options.mapTypeControl, true),
            scaleControl = valueOrDefault(options.scaleControl, true),
            streetViewControl = valueOrDefault(options.streetViewControl, true),
            overviewMapControl = valueOrDefault(overviewMapControl, true),
            map_options = {},
            map_base_options = {
                zoom: this.zoom,
                center: map_center,
                mapTypeId: mapType
            },
            map_controls_options = {
                panControl: panControl,
                zoomControl: zoomControl,
                zoomControlOptions: {
                    style: google.maps.ZoomControlStyle[zoomControlStyle],
                    position: google.maps.ControlPosition[zoomControlPosition]
                },
                mapTypeControl: mapTypeControl,
                scaleControl: scaleControl,
                streetViewControl: streetViewControl,
                overviewMapControl: overviewMapControl
            };

        if (typeof (options.el) === 'string' || typeof (options.div) === 'string') {
            if (identifier.indexOf("#") > -1) {
                /**
                 * Container element
                 *
                 * @type {HTMLElement}
                 */
                this.el = getElementById(identifier, options.context);
            } else {
                this.el = getElementsByClassName.apply(this, [identifier, options.context]);
            }
        } else {
            this.el = identifier;
        }

        if (typeof (this.el) === 'undefined' || this.el === null) {
            throw 'No element defined.';
        }

        window.context_menu = window.context_menu || {};
        window.context_menu[self.el.id] = {};

        /**
         * Collection of custom controls in the map UI
         *
         * @type {array}
         */
        this.controls = [];
        /**
         * Collection of map's overlays
         *
         * @type {array}
         */
        this.overlays = [];
        /**
         * Collection of KML/GeoRSS and FusionTable layers
         *
         * @type {array}
         */
        this.layers = [];
        /**
         * Collection of data layers (See {@link GMaps#addLayer})
         *
         * @type {object}
         */
        this.singleLayers = {};
        /**
         * Collection of map's markers
         *
         * @type {array}
         */
        this.markers = [];
        /**
         * Collection of map's lines
         *
         * @type {array}
         */
        this.polylines = [];
        /**
         * Collection of map's routes requested by {@link GMaps#getRoutes}, {@link GMaps#renderRoute}, {@link GMaps#drawRoute}, {@link GMaps#travelRoute} or {@link GMaps#drawSteppedRoute}
         *
         * @type {array}
         */
        this.routes = [];
        /**
         * Collection of map's polygons
         *
         * @type {array}
         */
        this.polygons = [];
        this.infoWindow = null;
        this.overlay_el = null;
        /**
         * Current map's zoom
         *
         * @type {number}
         */
        this.zoom = options.zoom;
        this.registered_events = {};

        this.el.style.width = options.width || this.el.scrollWidth || this.el.offsetWidth;
        this.el.style.height = options.height || this.el.scrollHeight || this.el.offsetHeight;

        google.maps.visualRefresh = options.enableNewStyle;

        for (i = 0; i < options_to_be_deleted.length; i++) {
            delete options[options_to_be_deleted[i]];
        }

        if (options.disableDefaultUI != true) {
            map_base_options = extend_object(map_base_options, map_controls_options);
        }

        map_options = extend_object(map_base_options, options);

        for (i = 0; i < events_that_hide_context_menu.length; i++) {
            delete map_options[events_that_hide_context_menu[i]];
        }

        for (i = 0; i < events_that_doesnt_hide_context_menu.length; i++) {
            delete map_options[events_that_doesnt_hide_context_menu[i]];
        }

        /**
         * Google Maps map instance
         *
         * @type {google.maps.Map}
         */
        this.map = new google.maps.Map(this.el, map_options);

        if (markerClustererFunction) {
            /**
             * Marker Clusterer instance
             *
             * @type {object}
             */
            this.markerClusterer = markerClustererFunction.apply(this, [this.map]);
        }

        var buildContextMenuHTML = function (control, e) {
            var html = '',
                options = window.context_menu[self.el.id][control];

            for (var i in options) {
                if (options.hasOwnProperty(i)) {
                    var option = options[i];

                    html += '<li><a id="' + control + '_' + i + '" href="#">' + option.title + '</a></li>';
                }
            }

            if (!getElementById('gmaps_context_menu')) return;

            var context_menu_element = getElementById('gmaps_context_menu');

            context_menu_element.innerHTML = html;

            var context_menu_items = context_menu_element.getElementsByTagName('a'),
                context_menu_items_count = context_menu_items.length,
                i;

            for (i = 0; i < context_menu_items_count; i++) {
                var context_menu_item = context_menu_items[i];

                var assign_menu_item_action = function (ev) {
                    ev.preventDefault();

                    options[this.id.replace(control + '_', '')].action.apply(self, [e]);
                    self.hideContextMenu();
                };

                google.maps.event.clearListeners(context_menu_item, 'click');
                google.maps.event.addDomListenerOnce(context_menu_item, 'click', assign_menu_item_action, false);
            }

            var position = findAbsolutePosition.apply(this, [self.el]),
                left = position[0] + e.pixel.x - 15,
                top = position[1] + e.pixel.y - 15;

            context_menu_element.style.left = left + "px";
            context_menu_element.style.top = top + "px";

            // context_menu_element.style.display = 'block';
        };

        this.buildContextMenu = function (control, e) {
            if (control === 'marker') {
                e.pixel = {};

                var overlay = new google.maps.OverlayView();
                overlay.setMap(self.map);

                overlay.draw = function () {
                    var projection = overlay.getProjection(),
                        position = e.marker.getPosition();

                    e.pixel = projection.fromLatLngToContainerPixel(position);

                    buildContextMenuHTML(control, e);
                };
            } else {
                buildContextMenuHTML(control, e);
            }

            var context_menu_element = getElementById('gmaps_context_menu');

            setTimeout(function () {
                context_menu_element.style.display = 'block';
            }, 0);
        };

        /**
         * Add a context menu for a map or a marker.
         *
         * @param {object} options - The `options` object should contain:
         * * `control` (string): Kind of control the context menu will be attached. Can be "map" or "marker".
         * * `options` (array): A collection of context menu items:
         *   * `title` (string): Item's title shown in the context menu.
         *   * `name` (string): Item's identifier.
         *   * `action` (function): Function triggered after selecting the context menu item.
         */
        this.setContextMenu = function (options) {
            window.context_menu[self.el.id][options.control] = {};

            var i,
                ul = doc.createElement('ul');

            for (i in options.options) {
                if (options.options.hasOwnProperty(i)) {
                    var option = options.options[i];

                    window.context_menu[self.el.id][options.control][option.name] = {
                        title: option.title,
                        action: option.action
                    };
                }
            }

            ul.id = 'gmaps_context_menu';
            ul.style.display = 'none';
            ul.style.position = 'absolute';
            ul.style.minWidth = '100px';
            ul.style.background = 'white';
            ul.style.listStyle = 'none';
            ul.style.padding = '8px';
            ul.style.boxShadow = '2px 2px 6px #ccc';

            if (!getElementById('gmaps_context_menu')) {
                doc.body.appendChild(ul);
            }

            var context_menu_element = getElementById('gmaps_context_menu');

            google.maps.event.addDomListener(context_menu_element, 'mouseout', function (ev) {
                if (!ev.relatedTarget || !this.contains(ev.relatedTarget)) {
                    window.setTimeout(function () {
                        context_menu_element.style.display = 'none';
                    }, 400);
                }
            }, false);
        };

        /**
         * Hide the current context menu
         */
        this.hideContextMenu = function () {
            var context_menu_element = getElementById('gmaps_context_menu');

            if (context_menu_element) {
                context_menu_element.style.display = 'none';
            }
        };

        var setupListener = function (object, name) {
            google.maps.event.addListener(object, name, function (e) {
                if (e == undefined) {
                    e = this;
                }

                options[name].apply(this, [e]);

                self.hideContextMenu();
            });
        };

        //google.maps.event.addListener(this.map, 'idle', this.hideContextMenu);
        google.maps.event.addListener(this.map, 'zoom_changed', this.hideContextMenu);

        for (var ev = 0; ev < events_that_hide_context_menu.length; ev++) {
            var name = events_that_hide_context_menu[ev];

            if (name in options) {
                setupListener(this.map, name);
            }
        }

        for (var ev = 0; ev < events_that_doesnt_hide_context_menu.length; ev++) {
            var name = events_that_doesnt_hide_context_menu[ev];

            if (name in options) {
                setupListener(this.map, name);
            }
        }

        google.maps.event.addListener(this.map, 'rightclick', function (e) {
            if (options.rightclick) {
                options.rightclick.apply(this, [e]);
            }

            if (window.context_menu[self.el.id]['map'] != undefined) {
                self.buildContextMenu('map', e);
            }
        });

        /**
         * Trigger a `resize` event, useful if you need to repaint the current map (for changes in the viewport or display / hide actions).
         */
        this.refresh = function () {
            google.maps.event.trigger(this.map, 'resize');
        };

        /**
         * Adjust the map zoom to include all the markers added in the map.
         */
        this.fitZoom = function () {
            var latLngs = [],
                markers_length = this.markers.length,
                i;

            for (i = 0; i < markers_length; i++) {
                if (typeof (this.markers[i].visible) === 'boolean' && this.markers[i].visible) {
                    latLngs.push(this.markers[i].getPosition());
                }
            }

            this.fitLatLngBounds(latLngs);
        };

        /**
         * Adjust the map zoom to include all the coordinates in the `latLngs` array.
         *
         * @param {array} latLngs - Collection of `google.maps.LatLng` objects.
         */
        this.fitLatLngBounds = function (latLngs) {
            var total = latLngs.length,
                bounds = new google.maps.LatLngBounds(),
                i;

            for (i = 0; i < total; i++) {
                bounds.extend(latLngs[i]);
            }

            this.map.fitBounds(bounds);
        };

        /**
         * Center the map using the `lat` and `lng` coordinates.
         *
         * @param {number} lat - Latitude of the coordinate.
         * @param {number} lng - Longitude of the coordinate.
         * @param {function} [callback] - Callback that will be executed after the map is centered.
         */
        this.setCenter = function (lat, lng, callback) {
            this.map.panTo(new google.maps.LatLng(lat, lng));

            if (callback) {
                callback();
            }
        };

        /**
         * Return the HTML element container of the map.
         *
         * @returns {HTMLElement} the element container.
         */
        this.getElement = function () {
            return this.el;
        };

        /**
         * Increase the map's zoom.
         *
         * @param {number} [magnitude] - The number of times the map will be zoomed in.
         */
        this.zoomIn = function (value) {
            value = value || 1;

            this.zoom = this.map.getZoom() + value;
            this.map.setZoom(this.zoom);
        };

        /**
         * Decrease the map's zoom.
         *
         * @param {number} [magnitude] - The number of times the map will be zoomed out.
         */
        this.zoomOut = function (value) {
            value = value || 1;

            this.zoom = this.map.getZoom() - value;
            this.map.setZoom(this.zoom);
        };

        var native_methods = [],
            method;

        for (method in this.map) {
            if (typeof (this.map[method]) == 'function' && !this[method]) {
                native_methods.push(method);
            }
        }

        for (i = 0; i < native_methods.length; i++) {
            (function (gmaps, scope, method_name) {
                gmaps[method_name] = function () {
                    return scope[method_name].apply(scope, arguments);
                };
            })(this, this.map, native_methods[i]);
        }
    };

    return GMaps;
})(this);
